package de.fhkl.strokeserver.core;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import de.fhkl.strokeprotocol.io.StrokeInputReader;
import de.fhkl.strokeprotocol.io.StrokeOutputWriter;
import de.fhkl.strokeprotocol.listeners.ICloseListener;
import de.fhkl.strokeprotocol.listeners.IMessageListener;
import de.fhkl.strokeprotocol.messages.AcceptRatingMessage;
import de.fhkl.strokeprotocol.messages.AcceptVideoMessage;
import de.fhkl.strokeprotocol.messages.IMessage;
import de.fhkl.strokeprotocol.messages.LoginAnswerMessage;
import de.fhkl.strokeprotocol.messages.LoginMessage;
import de.fhkl.strokeprotocol.messages.LogoutMessage;
import de.fhkl.strokeprotocol.messages.NewVideoMessage;
import de.fhkl.strokeprotocol.messages.PingMessage;
import de.fhkl.strokeprotocol.messages.RatingMessage;
import de.fhkl.strokeprotocol.messages.RecordingStartsMessage;
import de.fhkl.strokeprotocol.messages.RecordingStopsMessage;
import de.fhkl.strokeprotocol.messages.TransmissionCompletedMessage;
import de.fhkl.strokeprotocol.messages.VideoAssignedMessage;
import de.fhkl.strokeprotocol.messages.VideoMessage;
import de.fhkl.strokeprotocol.messages.VideoStartsMessage;
import de.fhkl.strokeserver.user.Doctor;
import de.fhkl.strokeserver.user.Paramedic;
import de.fhkl.strokeserver.user.User;

/**
 * Connection handler that handles all incoming connections.
 */
public class ConnectionHandler implements ICloseListener
{
	/** Init logger for this class */
	private static Logger sLogger = LoggerFactory.getLogger(ConnectionHandler.class);

	/** Lock object */
	private static Object sLock = new Object();

	/** Socket connection */
	private Socket mSocket;

	/** Output stream */
	private StrokeOutputWriter mOut;

	/** Input stream */
	private StrokeInputReader mIn;

	/** ID of the user that opened this connection */
	private UUID mSenderId;

	/** Indicates whether the connected user has successfully logged in. */
	private boolean mIsLoggedIn;

	/** Timestamp of latest activity */
	private Date mLatestActivity;

	/**
	 * Listener that wants to get informed when this connection handler has been
	 * stopped.
	 */
	protected ICloseListener mCloseListener;

	/** Indicates whether or not this connection handler should run. */
	protected boolean mRun;

	/**
	 * Connection handler that handles all incoming connections.
	 * 
	 * @param socket
	 * @throws IOException
	 */
	public ConnectionHandler(Socket socket) throws IOException
	{
		mSocket = socket;
		mOut = new StrokeOutputWriter(mSocket.getOutputStream());
		mIn = new StrokeInputReader(mSocket.getInputStream());

		registerListeners();

		mIn.start(this);

		mRun = true;
		
		mLatestActivity = new Date();

		sLogger.info("Connection handler successfully started.");
	}

	/**
	 * Closes the input and output stream and the socket
	 */
	public void close()
	{
		if (mRun)
		{
			sLogger.info("Closing connection handler...");

			mIsLoggedIn = false;

			mRun = false;

			// stop input reader
			if (mIn != null)
			{
				mIn.stop();
				mIn = null;
			}

			// close output writer
			try
			{
				if (mOut != null)
				{
					mOut.close();
					mOut = null;
				}
			}
			catch (IOException e)
			{
				sLogger.error(e.getMessage());
			}

			// close socket
			try
			{
				if (mSocket != null)
				{
					mSocket.close();
					mSocket = null;
				}

				sLogger.info("Connection handler successfully closed.");
			}
			catch (IOException e)
			{
				sLogger.error(e.getMessage());
			}

			// inform server that this connection handler has been stopped
			StrokeServer.getInstance().closeConnection(this);
		}
	}

	/**
	 * Registers all necessary listeners.
	 */
	private void registerListeners()
	{
		// login, logout, ping
		mIn.setMessageListener(LoginMessage.class, new LoginListener(this));
		mIn.setMessageListener(LogoutMessage.class, new LogoutListener());
		mIn.setMessageListener(PingMessage.class, new PingListener());

		// recording
		mIn.setMessageListener(RecordingStartsMessage.class, new RecordingStartsListener());
		mIn.setMessageListener(RecordingStopsMessage.class, new RecordingStopsListener());

		// video
		mIn.setMessageListener(VideoMessage.class, new VideoListener());
		mIn.setMessageListener(TransmissionCompletedMessage.class, new TransmissionCompletedListener());
		mIn.setMessageListener(AcceptVideoMessage.class, new AcceptVideoListener());
		mIn.setMessageListener(VideoStartsMessage.class, new VideoStartsListener());

		// rating
		mIn.setMessageListener(RatingMessage.class, new RatingListener());
		mIn.setMessageListener(AcceptRatingMessage.class, new AcceptRatingListener());
	}

	/**
	 * Forwards the given message to the given receiver.
	 * 
	 * @param message
	 * @param receiverId
	 */
	private void forward(IMessage message, UUID receiverId)
	{
		try
		{
			if (receiverId == null)
			{
				sLogger.error("Could not forward message, because the receiver's ID is not set.");
				return;
			}

			sLogger.info("Forwarding " + message.getClass().getSimpleName() + " from " + mSenderId + " to " + receiverId);

			ConnectionHandler receiver = StrokeServer.getInstance().getConnection(receiverId);

			if (receiver == null)
			{
				sLogger.error("Could not forward message, because of an unknown receiver ID.");
				return;
			}

			StrokeOutputWriter out = receiver.getOutputWriter();

			// send message
			out.send(message);
		}
		catch (Exception e)
		{
			sLogger.error(e.getMessage());
		}
	}

	/**
	 * Sends the given message to each receiver
	 * 
	 * @param message
	 * @param receivers
	 */
	private void broadcast(IMessage message, Collection<ConnectionHandler> receivers)
	{
		for (ConnectionHandler receiver : receivers)
		{
			StrokeOutputWriter out = receiver.getOutputWriter();

			try
			{
				out.send(message);
			}
			catch (Exception e)
			{
				sLogger.error(e.getMessage());
			}
		}
	}

	/**
	 * Get output writer that allows us to send messages to the connected
	 * client.
	 * 
	 * @return
	 */
	public StrokeOutputWriter getOutputWriter()
	{
		return mOut;
	}

	/**
	 * Get ID of the user that opened this connection
	 * 
	 * @return
	 */
	public UUID getSenderId()
	{
		return mSenderId;
	}

	/**
	 * Get the time of the latest activity.
	 * 
	 * @return
	 */
	public Date getLatestActivity()
	{
		return mLatestActivity;
	}

	@Override
	public void onClose(Object obj)
	{
		close();
	}

	/**
	 * Listener for LoginMessages.<br>
	 * Tries to login with the user credentials contained in this login message.
	 */
	private class LoginListener implements IMessageListener
	{
		ConnectionHandler mConnectionHandler;

		public LoginListener(ConnectionHandler connectionHandler)
		{
			mConnectionHandler = connectionHandler;
		}

		@Override
		public void onMessage(IMessage message)
		{
			LoginMessage loginMessage = (LoginMessage) message;

			try
			{
				sLogger.info("Incoming login message.");
				
				// try to get the user with the given email and password
				User user = MySQLConnection.getUser(loginMessage.getEmail(), loginMessage.getPassword(), loginMessage.getType());

				if (user != null)
				{
					mSenderId = user.getId();

					// if the user is already logged in in another
					// connection -> close that connection
					ConnectionHandler connection = StrokeServer.getInstance().getConnection(mSenderId);

					if (connection != null)
					{
						try
						{
							connection.close();
						}
						catch (Exception e)
						{
							sLogger.error(e.getMessage());
						}
					}

					// check if the user is a paramedic or a doctor and
					// add them to the correct list
					if (user.getClass() == Paramedic.class)
					{
						StrokeServer.getInstance().addParamedic(mConnectionHandler);
					}
					else if (user.getClass() == Doctor.class)
					{
						StrokeServer.getInstance().addDoctor(mConnectionHandler);
					}

					// send a message that informs the user that the
					// login was successful
					mOut.send(new LoginAnswerMessage(true));

					mIsLoggedIn = true;

					sLogger.info("Sent login answer (login successful, ID: " + mSenderId + ")");

					if (user.getClass() == Paramedic.class)
					{
						// check if there is a rating for a video sent by this
						// paramedic, that has been sent when the paramedic was
						// offline
						for (ActivityLog activity : StrokeServer.getInstance().getActivityLogs())
						{
							if (mSenderId.equals(activity.getParamedicId()) && null != activity.isRating()
									&& null == activity.getRatingAccepted())
							{
								UUID videoId = activity.getRecordingId();
								Boolean rating = activity.isRating();

								mOut.send(new RatingMessage(videoId, rating));
								sLogger.info("Inform paramedic about a rating, that has been sent when the paramedic was offline.");

								break;
							}
						}
					}
					else if (user.getClass() == Doctor.class)
					{
						// check if there are recordings, that are yet to be
						// accepted by a doctor
						for (ActivityLog activity : StrokeServer.getInstance().getActivityLogs())
						{
							// only videos that are already completely uploaded
							// to the server...
							if (activity.getVideoTransmissionCompleted() != null)
							{
								// ... and that have not yet been accepted
								if (null == activity.getVideoAccepted())
								{
									UUID videoId = activity.getRecordingId();
									List<byte[]> videoData = StrokeServer.getInstance().getVideoPartsMap().get(videoId);

									mOut.send(new NewVideoMessage(videoId, videoData.size()));

									sLogger.info("Inform user about recordings, that are yet to be accepted. (to " + mSenderId
											+ ")");
								}
							}
						}
					}

					return;
				}

				// login failed -> send a message that informs the user about it
				mOut.send(new LoginAnswerMessage(false));
				sLogger.info("Sent login answer (login failed)");
			}
			catch (Exception e)
			{
				sLogger.error(e.getMessage());
			}
		}
	}

	/**
	 * Listener for LogoutMessages.<br>
	 * Closes this connection.
	 */
	private class LogoutListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			sLogger.info("Incoming logout message from " + mSenderId);

			mIsLoggedIn = false;

			StrokeServer.getInstance().removeConnection(ConnectionHandler.this);
			
			mIn.stop();
			mIn = null;
			
			mSenderId = null;
		}
	}

	/**
	 * Listener for PingMessages.
	 */
	private class PingListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			sLogger.debug("Incoming ping message from " + mSenderId);

			try
			{
				boolean status = false;

				if ((StrokeServer.getInstance().getParamedic(mSenderId) != null && StrokeServer.getInstance().getDoctors().size() > 0)
						|| (StrokeServer.getInstance().getDoctor(mSenderId) != null && StrokeServer.getInstance().getParamedics()
								.size() > 0))
				{
					status = true;
				}

				// answer ping
				mOut.send(new PingMessage(status));

				sLogger.debug("Sent ping message.");

				// update time of the latest activity
				mLatestActivity = new Date();
			}
			catch (Exception e)
			{
				sLogger.error(e.getMessage());
			}
		}
	}

	/**
	 * Listener for RecordingStartsMessages.<br>
	 * Broadcasts the message to all doctors.
	 */
	private class RecordingStartsListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			RecordingStartsMessage startsMessage = (RecordingStartsMessage) message;

			sLogger.info("Incoming RecordingStartsMessage from " + mSenderId);

			if (mIsLoggedIn)
			{
				// add new activity log
				ActivityLog activity = new ActivityLog();
				activity.setParamedicId(mSenderId);
				activity.setRecordingId(startsMessage.getRecordingId());
				activity.setRecordingStarted(new Date());

				StrokeServer.getInstance().getActivityLogs().add(activity);
				MySQLConnection.insertActivityLog(activity);

				// broadcast message to all doctors
				broadcast(message, StrokeServer.getInstance().getDoctors());
			}
			else
			{
				sLogger.warn("RecordingStartsMessage from user that has not yet successfully logged in.");
			}
		}
	}

	/**
	 * Listener for RecordingStopsMessages.<br>
	 * Broadcasts the message to all doctors.
	 */
	private class RecordingStopsListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			RecordingStopsMessage stopsMessage = (RecordingStopsMessage) message;

			sLogger.info("Incoming RecordingStopsMessage from " + mSenderId);

			if (mIsLoggedIn)
			{
				// update activity log
				ActivityLog activity = StrokeServer.getInstance().getActivityLog(stopsMessage.getRecordingId());
				activity.setRecordingStopped(new Date());
				MySQLConnection.updateActivityLog(activity);

				// broadcast message to all doctors
				broadcast(message, StrokeServer.getInstance().getDoctors());
			}
			else
			{
				sLogger.warn("RecordingStopsMessage from user that has not yet successfully logged in.");
			}
		}
	}

	/**
	 * Listener for VideoMessages.<br>
	 * Saves the received video data in a map. The complete video will later be
	 * written to the HDD when the video transmission completed.<br>
	 * This listener will be called several times per transmission! Do not log
	 * these messages.
	 */
	private class VideoListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			VideoMessage videoMessage = (VideoMessage) message;

			if (mIsLoggedIn)
			{
				if (!StrokeServer.getInstance().getVideoPartsMap().containsKey(videoMessage.getVideoId()))
				{
					StrokeServer.getInstance().getVideoPartsMap().put(videoMessage.getVideoId(), new LinkedList<byte[]>());
				}

				List<byte[]> map = StrokeServer.getInstance().getVideoPartsMap().get(videoMessage.getVideoId());

				map.add(videoMessage.getVideoData());

				// update time of the latest activity
				mLatestActivity = new Date();
			}
		}
	}

	/**
	 * Listener for TransmissionCompletedMessages.<br>
	 * Saves all received parts of this video to the HDD and informs all doctors
	 * about the new video waiting for retrieval.
	 */
	private class TransmissionCompletedListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			TransmissionCompletedMessage completedMessage = (TransmissionCompletedMessage) message;

			sLogger.info("Incoming transmission completed message from " + mSenderId);

			if (!mIsLoggedIn)
			{
				sLogger.warn("AcceptRatingMessage from user that has not yet successfully logged in.");
				return;
			}

			// get information from message
			UUID videoId = completedMessage.getVideoId();

			// get list of video parts
			List<byte[]> videoParts = StrokeServer.getInstance().getVideoPartsMap().get(videoId);
			int numTotalParts = videoParts.size();

			sLogger.info("Received a total of " + numTotalParts + " parts.");

			// update activity log
			ActivityLog activity = StrokeServer.getInstance().getActivityLog(videoId);
			activity.setVideoTransmissionCompleted(new Date());
			MySQLConnection.updateActivityLog(activity);

			// inform all doctors about this new video waiting for retrieval
			NewVideoMessage newVideoMessage = new NewVideoMessage(videoId, numTotalParts);
			broadcast(newVideoMessage, StrokeServer.getInstance().getDoctors());

			// save video to HDD
			File file = new File(StrokeServer.getVideoPath() + videoId + ".mp4");
			FileOutputStream fos = null;

			try
			{
				fos = new FileOutputStream(file);

				for (byte[] videoData : videoParts)
				{
					fos.write(videoData);
				}
			}
			catch (Exception e)
			{
				sLogger.error(e.getMessage());
				return;
			}
			finally
			{
				if (fos != null)
				{
					try
					{
						fos.flush();
						fos.close();
						fos = null;
					}
					catch (IOException e)
					{
						sLogger.error(e.getMessage());
					}
				}
			}
		}
	}

	/**
	 * Listener for AcceptVideoMessages.<br>
	 * Forwards the AcceptVideoMessage to the receiver and sends the video to
	 * the doctor that requested it.
	 */
	private class AcceptVideoListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			AcceptVideoMessage acceptMessage = (AcceptVideoMessage) message;

			sLogger.info("Incoming accept video message from " + mSenderId + ". (" + acceptMessage.isAccepted() + ")");

			if (!mIsLoggedIn)
			{
				sLogger.warn("AcceptRatingMessage from user that has not yet successfully logged in.");
				return;
			}

			ActivityLog activity = StrokeServer.getInstance().getActivityLog(acceptMessage.getVideoId());

			synchronized (sLock)
			{
				// check if this is the first doctor that wants to accept this
				// video or if the first doctor wants to continue the
				// previously started download
				if (activity.getDoctorId() == null || mSenderId.equals(activity.getDoctorId()))
				{
					int startWithPart = acceptMessage.getStartWithPart();
					
					if (startWithPart == 0)
					{
						// forward message to paramedic
						forward(acceptMessage, activity.getParamedicId());
					}
					
					// inform all other doctors that this video has already been
					// accepted by someone else
					List<ConnectionHandler> doctors = new ArrayList<ConnectionHandler>(StrokeServer.getInstance().getDoctors());
					doctors.remove(ConnectionHandler.this);
					broadcast(new VideoAssignedMessage(acceptMessage.getVideoId()), doctors);

					// transfer video
					if (acceptMessage.isAccepted())
					{
						// update activity log
						activity.setDoctorId(mSenderId);
						activity.setVideoAccepted(new Date());
						MySQLConnection.updateActivityLog(activity);

						try
						{
							UUID videoId = acceptMessage.getVideoId();

							List<byte[]> videoParts = StrokeServer.getInstance().getVideoPartsMap().get(videoId);
							for (int i = startWithPart; i < videoParts.size(); i++)
							{
								byte[] videoData = videoParts.get(i);
								
								mOut.send(new VideoMessage(videoId, videoData));

								// update the time of the latest activity
								mLatestActivity = new Date();
							}

							mOut.send(new TransmissionCompletedMessage(videoId));

							sLogger.info("Video transfer complete.");
						}
						catch (Exception e)
						{
							sLogger.error("Video transfer failed: " + e.getMessage());
						}
					}
				}
			}
		}
	}

	/**
	 * Listener for VideoStartsMessages<br>
	 * Forwards the VideoStartsMessage to the receiver and removes the video
	 * from the map.
	 */
	private class VideoStartsListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			VideoStartsMessage startsMessage = (VideoStartsMessage) message;

			sLogger.info("Incoming VideoStartsMessage from " + mSenderId);

			if (mIsLoggedIn)
			{
				// update activity log
				ActivityLog activity = StrokeServer.getInstance().getActivityLog(startsMessage.getVideoId());
				activity.setVideoStarted(new Date());
				MySQLConnection.updateActivityLog(activity);

				// forward to paramedic
				forward(startsMessage, activity.getParamedicId());

				// remove video from map
				StrokeServer.getInstance().getVideoPartsMap().remove(startsMessage.getVideoId());
			}
			else
			{
				sLogger.warn("VideoStartsMessage from user that has not yet successfully logged in.");
			}
		}
	}

	/**
	 * Listener for RatingMessages.<br>
	 * Forwards received messages.
	 */
	private class RatingListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			RatingMessage ratingMessage = (RatingMessage) message;

			sLogger.info("Incoming RatingMessage from " + mSenderId);

			if (mIsLoggedIn)
			{
				// update activity log
				ActivityLog activity = StrokeServer.getInstance().getActivityLog(ratingMessage.getVideoId());
				activity.setRating(ratingMessage.getRating());
				MySQLConnection.updateActivityLog(activity);

				// forward to paramedic
				forward(message, activity.getParamedicId());
			}
			else
			{
				sLogger.warn("RatingMessage from user that has not yet successfully logged in.");
			}
		}
	}

	/**
	 * Listener for AcceptRatingMessages.<br>
	 * Forwards received messages.
	 */
	private class AcceptRatingListener implements IMessageListener
	{
		@Override
		public void onMessage(IMessage message)
		{
			AcceptRatingMessage acceptMessage = (AcceptRatingMessage) message;

			sLogger.info("Incoming AcceptRatingMessage from " + mSenderId);

			if (mIsLoggedIn)
			{
				// update activity log
				ActivityLog activity = StrokeServer.getInstance().getActivityLog(acceptMessage.getVideoId());
				activity.setRatingAccepted(new Date());
				MySQLConnection.updateActivityLog(activity);

				// forward to doctor
				forward(message, activity.getDoctorId());

				// remove this activity from the list of current activities
				// this won't effect the database entry
				StrokeServer.getInstance().getActivityLogs().remove(activity);
				activity = null;
			}
			else
			{
				sLogger.warn("AcceptRatingMessage from user that has not yet successfully logged in.");
			}
		}
	}
}